summaryrefslogtreecommitdiff
path: root/app/api/files/[...path]/route.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/files/[...path]/route.ts')
-rw-r--r--app/api/files/[...path]/route.ts244
1 files changed, 193 insertions, 51 deletions
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts
index f92dd1d8..e03187e3 100644
--- a/app/api/files/[...path]/route.ts
+++ b/app/api/files/[...path]/route.ts
@@ -1,74 +1,216 @@
// app/api/files/[...path]/route.ts
-import { NextRequest, NextResponse } from 'next/server'
-import { readFile } from 'fs/promises'
-import { join } from 'path'
-import { stat } from 'fs/promises'
+// /nas_evcp 경로에서 파일을 서빙하는 API (다운로드 강제 기능 추가)
+
+import { NextRequest, NextResponse } from "next/server";
+import { promises as fs } from "fs";
+import path from "path";
+
+const nasPath = process.env.NAS_PATH || "/evcp_nas"
+
+// MIME 타입 매핑
+const getMimeType = (filePath: string): string => {
+ const ext = path.extname(filePath).toLowerCase();
+ const mimeTypes: Record<string, string> = {
+ '.pdf': 'application/pdf',
+ '.doc': 'application/msword',
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ '.xls': 'application/vnd.ms-excel',
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.txt': 'text/plain',
+ '.zip': 'application/zip',
+ };
+
+ return mimeTypes[ext] || 'application/octet-stream';
+};
+
+// 보안: 허용된 디렉토리 체크
+const isAllowedPath = (requestedPath: string): boolean => {
+ const allowedPaths = [
+ 'basicContract',
+ 'basicContract/template',
+ 'basicContract/signed',
+ 'vendorFormReportSample',
+ 'vendorFormData',
+ ];
+
+ return allowedPaths.some(allowed =>
+ requestedPath.startsWith(allowed) || requestedPath === allowed
+ );
+};
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
+ // 요청된 파일 경로 구성
+ const requestedPath = params.path.join('/');
+
+ console.log(`📂 파일 요청: ${requestedPath}`);
+
+ // ✅ 다운로드 강제 여부 확인
+ const url = new URL(request.url);
+ const forceDownload = url.searchParams.get('download') === 'true';
+
+ console.log(`📥 다운로드 강제 모드: ${forceDownload}`);
+
+ // 보안 체크: 허용된 경로인지 확인
+ if (!isAllowedPath(requestedPath)) {
+ console.log(`❌ 허용되지 않은 경로: ${requestedPath}`);
+ return new NextResponse('Forbidden', { status: 403 });
+ }
+
+ // 경로 트래버설 공격 방지
+ if (requestedPath.includes('..') || requestedPath.includes('~')) {
+ console.log(`❌ 위험한 경로 패턴: ${requestedPath}`);
+ return new NextResponse('Bad Request', { status: 400 });
+ }
- const path = request.nextUrl.searchParams.get("path");
+ // 환경에 따른 파일 경로 설정
+ let filePath: string;
+
+ if (process.env.NODE_ENV === 'production') {
+ // ✅ 프로덕션: NAS 경로 사용
+ filePath = path.join(nasPath, requestedPath);
+ } else {
+ // 개발: public 폴더
+ filePath = path.join(process.cwd(), 'public', requestedPath);
+ }
+ console.log(`📁 실제 파일 경로: ${filePath}`);
- // 경로 파라미터에서 파일 경로 조합
- const filePath = join(process.cwd(), 'uploads', ...params.path)
-
// 파일 존재 여부 확인
try {
- await stat(filePath)
- } catch (error) {
- return NextResponse.json(
- { error: 'File not found' },
- { status: 404 }
- )
+ await fs.access(filePath);
+ } catch {
+ console.log(`❌ 파일 없음: ${filePath}`);
+ return new NextResponse('File not found', { status: 404 });
}
-
+
+ // 파일 통계 정보 가져오기
+ const stats = await fs.stat(filePath);
+ if (!stats.isFile()) {
+ console.log(`❌ 파일이 아님: ${filePath}`);
+ return new NextResponse('Not a file', { status: 400 });
+ }
+
// 파일 읽기
- const fileBuffer = await readFile(filePath)
+ const fileBuffer = await fs.readFile(filePath);
- // 파일 확장자에 따른 MIME 타입 설정
- const fileName = params.path[params.path.length - 1]
- const fileExtension = fileName.split('.').pop()?.toLowerCase()
+ // MIME 타입 결정
+ const mimeType = getMimeType(filePath);
+ const fileName = path.basename(filePath);
+
+ console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`);
+
+ // ✅ Content-Disposition 헤더 결정
+ const contentDisposition = forceDownload
+ ? `attachment; filename="${fileName}"` // 강제 다운로드
+ : `inline; filename="${fileName}"`; // 브라우저에서 열기
+
+ // Range 요청 처리 (큰 파일의 부분 다운로드 지원)
+ const range = request.headers.get('range');
- let contentType = 'application/octet-stream'
+ if (range) {
+ const parts = range.replace(/bytes=/, "").split("-");
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
+ const chunksize = (end - start) + 1;
+ const chunk = fileBuffer.slice(start, end + 1);
+
+ return new NextResponse(chunk, {
+ status: 206,
+ headers: {
+ 'Content-Range': `bytes ${start}-${end}/${stats.size}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': chunksize.toString(),
+ 'Content-Type': mimeType,
+ 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용
+ },
+ });
+ }
+
+ // 일반 파일 응답
+ return new NextResponse(fileBuffer, {
+ headers: {
+ 'Content-Type': mimeType,
+ 'Content-Length': stats.size.toString(),
+ 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용
+ 'Cache-Control': 'public, max-age=31536000', // 1년 캐시
+ 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`,
+ // ✅ 추가 보안 헤더
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ });
+
+ } catch (error) {
+ console.error('❌ 파일 서빙 오류:', error);
+ return new NextResponse('Internal Server Error', { status: 500 });
+ }
+}
+
+// HEAD 요청 지원 (파일 정보만 확인)
+export async function HEAD(
+ request: NextRequest,
+ { params }: { params: { path: string[] } }
+) {
+ try {
+ const requestedPath = params.path.join('/');
- if (fileExtension) {
- const mimeTypes: Record<string, string> = {
- 'pdf': 'application/pdf',
- 'doc': 'application/msword',
- 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'xls': 'application/vnd.ms-excel',
- 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'ppt': 'application/vnd.ms-powerpoint',
- 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'txt': 'text/plain',
- 'csv': 'text/csv',
- 'png': 'image/png',
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'gif': 'image/gif',
- }
-
- contentType = mimeTypes[fileExtension] || contentType
+ // ✅ HEAD 요청에서도 다운로드 강제 여부 확인
+ const url = new URL(request.url);
+ const forceDownload = url.searchParams.get('download') === 'true';
+
+ if (!isAllowedPath(requestedPath)) {
+ return new NextResponse(null, { status: 403 });
}
- // 다운로드 설정
- const headers = new Headers()
- headers.set('Content-Type', contentType)
- headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
+ if (requestedPath.includes('..') || requestedPath.includes('~')) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ let filePath: string;
- return new NextResponse(fileBuffer, {
- status: 200,
- headers,
- })
+ if (process.env.NODE_ENV === 'production') {
+ filePath = path.join(nasPath, requestedPath);
+ } else {
+ filePath = path.join(process.cwd(), 'public', requestedPath);
+ }
+
+ try {
+ const stats = await fs.stat(filePath);
+ if (!stats.isFile()) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ const mimeType = getMimeType(filePath);
+ const fileName = path.basename(filePath);
+
+ // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용
+ const contentDisposition = forceDownload
+ ? `attachment; filename="${fileName}"` // 강제 다운로드
+ : `inline; filename="${fileName}"`; // 브라우저에서 열기
+
+ return new NextResponse(null, {
+ headers: {
+ 'Content-Type': mimeType,
+ 'Content-Length': stats.size.toString(),
+ 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용
+ 'Last-Modified': stats.mtime.toUTCString(),
+ 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ });
+ } catch {
+ return new NextResponse(null, { status: 404 });
+ }
+
} catch (error) {
- console.error('Error downloading file:', error)
- return NextResponse.json(
- { error: 'Failed to download file' },
- { status: 500 }
- )
+ console.error('File HEAD error:', error);
+ return new NextResponse(null, { status: 500 });
}
} \ No newline at end of file